Skip to content

Conversation

admin-coderabbit
Copy link
Owner

@admin-coderabbit admin-coderabbit commented Feb 4, 2026

This pull request was automatically created by @coderabbitai/e2e-reviewer.

Batch created pull request.

Summary by CodeRabbit

New Features

  • Added backup codes for two-factor authentication, generated when users enable 2FA
  • Users can download or copy backup codes for safekeeping
  • Added "Lost Access" option during login to use backup codes if authenticator is unavailable
  • Backup codes serve as an alternative authentication method when disabling 2FA

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
@coderabbit-eval
Copy link

coderabbit-eval bot commented Feb 4, 2026

📝 Walkthrough

Walkthrough

This PR introduces backup codes as a secondary authentication method for two-factor authentication. Users can generate backup codes during 2FA setup, use them to disable 2FA without their authenticator, and authenticate during login. The feature spans UI components, authentication flows, database schema, and error handling.

Changes

Cohort / File(s) Summary
Backup Code UI Components
apps/web/components/auth/BackupCode.tsx, apps/web/components/auth/TwoFactor.tsx
New BackupCode component for entering backup codes; TwoFactor component adds conditional autoFocus prop to control auto-focus behavior of digit inputs.
2FA Modal Updates
apps/web/components/settings/DisableTwoFactorModal.tsx, apps/web/components/settings/EnableTwoFactorModal.tsx
Disable modal adds backup code validation path and UI toggle. Enable modal introduces backup code generation, display panel with copy/download actions, and new DisplayBackupCodes setup step.
2FA API & Setup Logic
apps/web/components/settings/TwoFactorAuthAPI.ts, apps/web/pages/api/auth/two-factor/totp/setup.ts, apps/web/pages/api/auth/two-factor/totp/disable.ts
Setup endpoint generates and encrypts 10 backup codes in user record. Disable endpoint validates backup code or TOTP. API client now accepts backup code parameter.
Authentication & Login Flow
packages/features/auth/lib/next-auth-options.ts, apps/web/pages/auth/login.tsx
Login page adds backup code entry branch with lost access toggle. NextAuth adds credentials validation logic for backup codes, decryption, and invalidation of used codes.
Error Handling & Localization
packages/features/auth/lib/ErrorCode.ts, apps/web/public/static/locales/en/common.json
New error codes for backup code scenarios (IncorrectBackupCode, MissingBackupCodes). New i18n keys for UI strings and instructions.
Data Layer
packages/prisma/migrations/20230804153419_add_backup_codes/migration.sql, packages/prisma/schema.prisma
Database migration adds optional backupCodes TEXT column to users table and schema model definition.
Testing & Utilities
apps/web/playwright/login.2fa.e2e.ts, packages/lib/test/builder.ts
E2E tests for backup code download and copy workflows. Test builder adds backupCodes field to UserPayload.
UI Component Enhancement
packages/ui/components/form/inputs/Input.tsx
Minor enhancement: PasswordField toggle button now includes tabIndex={-1} to prevent tab focus.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Client as Client (Web)
    participant Server as Server
    participant DB as Database

    User->>Client: 1. Initiate 2FA setup
    Client->>Server: POST /totp/setup
    Server->>Server: Generate secret & 10 backup codes
    Server->>Server: Encrypt backup codes with CALENDSO_ENCRYPTION_KEY
    Server->>DB: Save encrypted backupCodes, secret
    Server-->>Client: Return secret, keyUri, dataUri, backupCodes
    Client->>User: Display QR code, then DisplayBackupCodes step
    User->>Client: Download/Copy backup codes
    User->>Client: Confirm setup complete
    Client->>Server: POST /totp/enable (verify QR scan)
    Server->>DB: Confirm twoFactorEnabled: true
    Server-->>Client: Success

    rect rgba(200, 150, 255, 0.5)
    Note over User,DB: Backup Code Usage
    end

    User->>Client: Lost access to authenticator
    Client->>Client: Toggle twoFactorLostAccess state
    Client->>User: Show BackupCode input
    User->>Client: Enter backup code
    Client->>Server: POST credentials with backupCode
    Server->>Server: Decrypt user.backupCodes
    Server->>Server: Validate code, mark as used (set to null)
    Server->>Server: Re-encrypt remaining codes
    Server->>DB: Update user with invalidated code
    Server-->>Client: Return JWT (authenticated)
    Client-->>User: Login success
Loading
sequenceDiagram
    participant User as User
    participant Client as Client (Web)
    participant Server as Server
    participant DB as Database

    rect rgba(255, 100, 100, 0.5)
    Note over User,DB: Disable 2FA with Backup Code
    end

    User->>Client: Click "Disable 2FA"
    Client->>User: Show password + TOTP input
    User->>Client: Can't access authenticator
    User->>Client: Click "Lost Access"
    Client->>Client: Toggle twoFactorLostAccess, show BackupCode
    User->>Client: Enter password + backup code
    Client->>Server: POST /disable with password, backupCode
    Server->>Server: Decrypt user.backupCodes
    Server->>Server: Validate backup code against decrypted list
    alt Backup code valid
        Server->>DB: Update user: twoFactorEnabled: false, backupCodes: null
        Server-->>Client: Success
        Client-->>User: 2FA disabled
    else Backup code invalid
        Server-->>Client: Error (IncorrectBackupCode)
        Client-->>User: Show error message
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Hoppity-hop, codes are backed up with care,
When access is lost, recovery's there!
Encryption and toggles, login flows new,
Verify and validate—the 2FA's true!
*generates ten codes with a magical thump 🔐

🚥 Pre-merge checks | ✅ 1 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Title check ❓ Inconclusive The title refers to improving two-factor authentication features, which matches the core functionality added across multiple files (backup codes, enhanced 2FA flow, new components), but it is vague and generic without specifying what improvements were made. Consider a more descriptive title such as 'Add backup codes support to two-factor authentication' or 'Implement backup code flow for 2FA recovery' to clearly convey the primary change.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch improve-two-factor-authentication-features

Comment @coderabbit eval help to get the list of available commands and usage tips.

@coderabbit-eval coderabbit-eval bot closed this Feb 4, 2026
Copy link

@coderabbit-eval coderabbit-eval bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@apps/web/pages/api/auth/two-factor/totp/disable.ts`:
- Around line 58-63: Normalize the incoming backup code before lookup: take
req.body.backupCode, trim surrounding whitespace, remove internal whitespace and
dashes (e.g., replace spaces and "-" characters), convert to the same case used
in stored backupCodes (e.g., toLowerCase()), then check indexOf against
backupCodes; update the lookup that currently does
backupCodes.indexOf(req.body.backupCode.replaceAll("-", "")) to use this
normalized value and keep the existing error return
(ErrorCode.IncorrectBackupCode) unchanged.

In `@apps/web/playwright/login.2fa.e2e.ts`:
- Around line 50-54: The current assertion uses
page.locator('[data-testid=two-factor-switch]').isChecked() which returns a
Promise<boolean> and is not awaited, so the test asserts the Promise object
itself; change the assertion to either await the boolean (await
page.locator('[data-testid=two-factor-switch]').isChecked() and assert the
result) or — preferably — use the Playwright locator matcher on the locator
(e.g., expect(page.locator('[data-testid=two-factor-switch]')).toBeChecked()) to
get proper awaiting and auto-retry behavior.

In `@packages/ui/components/form/inputs/Input.tsx`:
- Around line 47-51: The password visibility toggle button in Input.tsx is
currently removed from keyboard focus by tabIndex={-1}; update the button (the
element that calls toggleIsPasswordVisible()) to be keyboard-focusable by
removing the tabIndex or setting it to 0, and ensure it has an accessible name
(e.g., aria-label or visible text) so keyboard and screen-reader users can
operate the toggle.
🧹 Nitpick comments (2)
packages/features/auth/lib/next-auth-options.ts (1)

131-155: Normalize and prune backup codes after use (robustness).

Trimming/lowercasing prevents false negatives on pasted codes, and pruning used codes avoids persisting dead entries and lets you null out the field when exhausted.

♻️ Suggested tweak
-        const backupCodes = JSON.parse(
-          symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
-        );
+        const backupCodes = JSON.parse(
+          symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY)
+        ) as Array<string | null>;
+
+        const normalizedBackupCode = credentials.backupCode.replaceAll("-", "").trim().toLowerCase();

-        const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
+        const index = backupCodes.indexOf(normalizedBackupCode);
         if (index === -1) throw new Error(ErrorCode.IncorrectBackupCode);

-        backupCodes[index] = null;
+        backupCodes[index] = null;
+        const remainingBackupCodes = backupCodes.filter((code): code is string => !!code);
         await prisma.user.update({
           where: {
             id: user.id,
           },
           data: {
-            backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
+            backupCodes: remainingBackupCodes.length
+              ? symmetricEncrypt(JSON.stringify(remainingBackupCodes), process.env.CALENDSO_ENCRYPTION_KEY)
+              : null,
           },
         });
apps/web/components/settings/EnableTwoFactorModal.tsx (1)

64-65: Clear backup‑code artifacts on reset/close.
The download blob URL and backup‑code data stay in memory after the modal closes, which leaves sensitive data and an object URL lingering. Consider clearing state and revoking the URL when the flow ends.

♻️ Suggested cleanup
 const resetState = () => {
   setPassword("");
   setErrorMessage(null);
   setStep(SetupStep.ConfirmPassword);
+  setBackupCodes([]);
+  setDataUri("");
+  setSecret("");
+  if (backupCodesUrl) {
+    URL.revokeObjectURL(backupCodesUrl);
+    setBackupCodesUrl("");
+  }
 };

Also applies to: 71-75, 92-100, 261-287

Comment on lines +58 to +63
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));

// check if user-supplied code matches one
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
if (index === -1) {
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize backup code input before lookup.
Users often paste codes with whitespace or uppercase; without normalization, valid codes can be rejected.

🧹 Suggested fix
-    const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
+    const normalizedBackupCode = req.body.backupCode.replaceAll("-", "").trim().toLowerCase();
+    const index = backupCodes.indexOf(normalizedBackupCode);
🤖 Prompt for AI Agents
In `@apps/web/pages/api/auth/two-factor/totp/disable.ts` around lines 58 - 63,
Normalize the incoming backup code before lookup: take req.body.backupCode, trim
surrounding whitespace, remove internal whitespace and dashes (e.g., replace
spaces and "-" characters), convert to the same case used in stored backupCodes
(e.g., toLowerCase()), then check indexOf against backupCodes; update the lookup
that currently does backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""))
to use this normalized value and keep the existing error return
(ErrorCode.IncorrectBackupCode) unchanged.

Comment on lines +50 to 54
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -name "login.2fa.e2e.ts" -type f

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 145


🏁 Script executed:

cat -n apps/web/playwright/login.2fa.e2e.ts | head -70 | tail -30

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1288


🏁 Script executed:

rg "\.isChecked\(\)" --type ts --type js -B 2 -A 2

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 532


🏁 Script executed:

rg "toHaveAttribute.*data-state" --type ts --type js -B 1 -A 1

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 72


🏁 Script executed:

rg "data-state.*checked" apps/web/playwright/login.2fa.e2e.ts -B 3 -A 3

Repository: admin-coderabbit/coderabbit-cal_dot_com-mirror

Length of output: 1704


🌐 Web query:

Playwright locator.isChecked() Promise expect toBeTruthy behavior

💡 Result:

  • locator.isChecked() returns a Promise<boolean> (it resolves to true/false, and throws if the element isn’t a checkbox/radio). So you must await it before using a generic matcher like toBeTruthy(). If you do expect(locator.isChecked()).toBeTruthy() without await, you’re asserting that a Promise object is truthy (it is), not that the checkbox is checked. [1]
  • Also, expect(await locator.isChecked()).toBeTruthy() is a one-time check and does not auto-wait/retry, so it can be flaky if the UI updates asynchronously. Playwright recommends using the locator assertion instead: await expect(locator).toBeChecked(), which auto-retries until the expect timeout. [1][2][3]

Recommended

await expect(page.getByRole('checkbox', { name: 'Subscribe' })).toBeChecked();

If you really want isChecked()

expect(await locator.isChecked()).toBeTruthy(); // no auto-wait

Sources: [1] Playwright locator.isChecked() docs, [2] Playwright expect(locator).toBeChecked() docs, [3] Playwright assertions (generic vs auto-retrying async assertions).


Fix the 2FA switch assertion — the Promise is never awaited.

The assertion expect(page.locator('[data-testid=two-factor-switch]').isChecked()).toBeTruthy() passes on the Promise object itself (which is always truthy) rather than the resolved boolean value. Since isChecked() returns Promise<boolean>, it must be awaited before assertion. Use a Playwright matcher on the locator instead, which also provides auto-retry behavior:

-      await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
+      await expect(page.locator(`[data-testid=two-factor-switch]`)).toHaveAttribute(
+        "data-state",
+        "checked"
+      );

This approach aligns with the working pattern elsewhere in this test file.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`)).toHaveAttribute(
"data-state",
"checked"
);
🤖 Prompt for AI Agents
In `@apps/web/playwright/login.2fa.e2e.ts` around lines 50 - 54, The current
assertion uses page.locator('[data-testid=two-factor-switch]').isChecked() which
returns a Promise<boolean> and is not awaited, so the test asserts the Promise
object itself; change the assertion to either await the boolean (await
page.locator('[data-testid=two-factor-switch]').isChecked() and assert the
result) or — preferably — use the Playwright locator matcher on the locator
(e.g., expect(page.locator('[data-testid=two-factor-switch]')).toBeChecked()) to
get proper awaiting and auto-retry behavior.

Comment on lines +47 to +51
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore keyboard access to the password visibility toggle.

tabIndex={-1} removes the button from the tab order, so keyboard users can’t toggle password visibility.

✅ Suggested fix
-              tabIndex={-1}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
className="text-emphasis h-9"
tabIndex={-1}
type="button"
onClick={() => toggleIsPasswordVisible()}>
<button
className="text-emphasis h-9"
type="button"
onClick={() => toggleIsPasswordVisible()}>
🤖 Prompt for AI Agents
In `@packages/ui/components/form/inputs/Input.tsx` around lines 47 - 51, The
password visibility toggle button in Input.tsx is currently removed from
keyboard focus by tabIndex={-1}; update the button (the element that calls
toggleIsPasswordVisible()) to be keyboard-focusable by removing the tabIndex or
setting it to 0, and ensure it has an accessible name (e.g., aria-label or
visible text) so keyboard and screen-reader users can operate the toggle.

Sign in to join this conversation on GitHub.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

1 participant